0x00 写在前面

本实验是对 CVE-2018-1160 漏洞的调试分析和复现,所有实验过程均在本地搭建的虚拟机中进行。通过该实验比较深入地理解了该漏洞的成因和利用过程。

请严格遵守所在地法律法规。

0x01 程序分析

漏洞基本信息

Netatalk 是一个 Apple Filing Protocol(AFP) 的开源实现,为 Unix 系统提供了与 Macintosh 文件共享的功能。

CVE-2018-1160 是一个堆溢出漏洞,在 3.1.12 前的版本上在处理网络数据进行写入时未对写入数据长度进行判断,存在覆写漏洞,攻击者可以通过精巧的构建payload实现任意地址写。触发次数,可以多次发包,可以长时间反复发包。

根本原因就是在拷贝网络数据到结构体中时未对数据长度进行判断,造成溢出,导致攻击者可以进行任意内容的覆写,通过精巧的构建可以实现任意地址写,从而利用稳定的攻击路线实现getshell。

本实验是在 Ubuntu 18.04 x64 上开启 ASLR、NX、PIE、CANARY、FORTIFY;RELRO:FULL 等保护机制等情况下,实现远程 getshell 。

漏洞分析

漏洞点分析

netatalk-3.1.11/libatalk/dsi/dsi_opensess.c:34 处,注意到 dsi_opensession() 中的 memcpy(&dsi->attn_quantum, dsi->commands + i + 1, dsi->commands[i]); 并没有检查 dsi->commands[1] 的长度,而且 dsi->commands[2] 是我们发送的包里面的内容,也就是说 memcpy 中的数据内容、长度都是可控的,我们可以覆写 dsi 结构体里 dsi->attn_quantum 之后的内容。核心代码如下

漏洞点函数

因此这个地方存在一个溢出,用户可以对目标缓冲区 &dsi->attn_quantum 后的内容进行覆写。

任意地址写

在上述的越界写存在后,需要观测被越界写的地址后都有什么样的数据,所以需要观测目标地址的情况。

我们去观测一下 DSI 结构体,在 netatalk-3.1.11/include/atalk/dsi.h:60 中定义了 struct DSI ,如下图所示

DSI结构

从这个定义中可以看到溢出位置往后还有众多的变量和指针。需要注意的是,上述漏洞位置控制数据拷贝长度的值是 dsi->commands[1] ,因此这个长度最多只能是一个字节,即 0xff,因此往后最多能写的位置并不多,而通过观测我们可以发现在 attn_quantum 后面有一个超大的缓冲区 uint8_t data[DSI_DATASIZ] ,长度有 65536 ,因此,我们最多只能覆盖到这个缓冲区的部分。而这其中有一个有意思的域是 uint8_t *commands; /* DSI recieve buffer */ 从注释中能知道,这个域是用于接收 DSI 数据的缓冲区。

简单跟踪该域的使用位置,可以发现在 /netatalk-3.1.11/libatalk/dsi/dsi_stream.c:634 处, commands 被传递进入了函数 dsi_stream_read ,而继续跟踪调用链 dsi_stream_read(dsi, dsi->commands, dsi->cmdlen)-> buf_read(dsi, (uint8_t *) data + stored, length - stored)-> from_buf(dsi, buf, count)-> memcpy(buf, dsi->start, nbe); 可以发现最终是将网络数据包中的内容写入到了 commands 所指向的地址中。

dsi_stream_read

这里我将控制流中比较重要的部分剥离出来形成了一个单独的控制流代码如下

控制流跟踪

因此,任意地址写的方法就有了,通过第一次发包,利用漏洞点溢出覆盖 attn_quantum+16 位置的 commands 为希望写入数据的地址,然后再发送一次数据包包含希望写入的值,从而在第二次触发向 commands 所指向的内存位置写入任意的值。

爆破 libc 基地址

根据初始化 DSI 的过程, dsi->commandsmalloc 分配的,但是 dsi->server_quantum 的初始值为 DSI_SERVQUANT_DEF = 0x100000L ,超过了 brk() 能分配的范围,所以是 mmap 分配的内存空间。

而当使用前面的任意地址写任意值时,如果尝试在一个不合法地址写入数据将会导致程序的崩溃,而程序一旦崩溃则无法进行响应返回。

Netatalk 工作的过程中,是由主进程来不断监听过来的请求,然后 fork 子进程对具体的请求进行处理,因此父进程和子进程享用共同的 mmap 基地址。

因此,通过不断尝试写入的地址,观测响应数据是否正常来爆破出一个合法的地址。需要注意的是,如果直接爆破有可能爆破出来的地址比 libc.so 的地址高,也有可能比 libc.so 的地址低,原因是只要能够进行写的操作那就是合法的地址,但 mmap 的地址在调试过程中是比 libc.so 的地址要高的,所以可以进行部分位的倒序爆破。爆破出来 mmap 地址就能爆破出 libc 的基址了,自然就可以改写地址进行控制流劫持了,通过不断往前做减法,一直枚举尝试一个可行的范围即可。

劫持控制流

在拥有任意地址写任意数据的能力后,并且成功得到了 libc 的基地址,所以后续的流程是相对固定的。这里采用劫持 __free_hook 的方法。通过 SROP 构建一个 gadget 链执行 system[cmd]。在 libc2.77.so 中有一个很方便的 gadget:setcontext ,这个 gadget 会依次对寄存器进行赋值,值的内容和 rdi 有关。

setcontext

因此,只要我们能够控制 rdi 寄存器,那么我们就能控制几乎所有的寄存器,包括 rsprip ,也就是说我们就达成了劫持控制流、控制了几乎所有寄存器。

这一段 gadgets 其实就是在进行 SROPsignal frame 的构建,此时 rdi 相对于指向就是 signal frame 的顶部。因此,我们可以通过 pwntools 中的 SigreturnFrame 方便的控制这段代码对寄存器的赋值,只要我们可以控制 rdi

但是这样就又要控制 rdi 并且要求 rdi 指向的内存可控。为了控制 rdi ,我们需要 __libc_dlopen_mode+0x35 处的一段 gadget。这一段代码将_dl_open_hook 指向的内存赋给 rax,然后调用rax指向的内存地址. _dl_open_hook 的位置位于 _free_hook + 0x2BC0,且 *_dl_open_hook = _dl_open_hook + 8 。如图所示

dl_open_hook

然后 ROPgadget 找一下 mov rdi, rax 相关的内容,可以找到一个是 fgetpos64+0xcf ,如图所示

fgetpos64+0xcf

所以拼起来就是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
; __libc_dlopen_mode+0x35
mov rax, cs:dl_open_hook
call qword ptr [rax]

; fgetpos64+0xcf
mov rdi, rax
call qword ptr [rax + 20h]

; setcontext + 56
mov rsp, [rdi+0A0h]
mov rbx, [rdi+80h]
mov rbp, [rdi+78h]
mov r12, [rdi+48h]
mov r13, [rdi+50h]
mov r14, [rdi+58h]
mov r15, [rdi+60h]
mov rcx, [rdi+0A8h]
push rcx
mov rsi, [rdi+70h]
mov rdx, [rdi+88h]
mov rcx, [rdi+98h]
mov r8, [rdi+28h]
mov r9, [rdi+30h]
mov rdi, [rdi+68h]
xor eax, eax
retn

libc2.27 中,_dl_open_hook 地址比 free_hook 大约高 0x2b00 左右(不同版本编译器编译出来的 libc2.27 可能略有差别,但总体大约 0x2b00 左右),在本文中能够覆盖到,因此我们将 commands 指针覆盖至 free_hook 的地址处,随后根据三条 gadgets 的调用链,依次往后布局内存,使得我们最终能够控制 rdi ,进而控制程序流以及几乎所有寄存器,完成 RCE

注意,因为 rdi 距离 SigReturnFrame0x28 字节的距离,所以 SigReturnFrame 要跳过前 0x28 字节。

最终内存布局图

这里本文的利用过程中与参考博客一致,比较粗糙,直接从 0x52085 处开始使用 ROP ,这导致了第一条指令是 mov rsp, [rdi+0xa0] ,于是乎,我们将这个 ROP 放上去之后,程序执行过程中会将 rsp 寄存器的值改掉,而我们后续需要使用下面的 0x520ae 处的指令 push rcx0x520cf 处的 ret 来实现将 rcx 寄存器压栈后又弹出到 rip 以实现将 rip 寄存器劫持到 system 的目的。所以我们就需要再给我们覆盖到 rsp 的值找到一个可以写入的地址,用来伪造成 push rcx 时候的栈顶,所以这里才需要去设计一下,让 rdi+0xa0 处的值要成为 __free_hook 处,这是一个可以写入的地址罢了。

事实上,如果我们细致一点,从 0x5208c 处开始我们的 ROP ,则 rsp 的值就不会被覆盖,于是就可以直接使用原来的栈进行 systemrip 的传递,毕竟原来的栈,天然就是一个可写的地址。

漏洞总体利用思路

可以通过任意地址写任意内容破坏重要数据结构,利用 fork 机制下 server 的响应内容对 libc 基地址进行爆破,最后通过劫持 free_hook 控制流实现 RCE

  1. 爆破可写地址。通过不断尝试,从高到低爆破出第一个可写入地址从而爆破出libc的基地址。通过第一次控制溢出长度覆写 DSI 结构体后面的 commands 指针的值,然后通过第二次发送数据往 commands 指向位置写入任意值。
  2. 劫持通过 free_hook 劫持程序控制流实现 RCE 。通过覆盖到 _dl_open_hook 让控制流走入 gadget 中,然后控制 rdi ,然后利用 setcontext 控制其他寄存器从而实现劫持所有寄存器实现 RCE

地址泄露

由前面的分析,通过第一次发包可以替换 commands 为希望写入数据的地址,第二次发包为向该地址中写入数据。而如果写入数据的地址无效或者不合法将会触发程序报错,否则将不会报错。因此通过这个特性可以判断第一次放入的地址是否是一个可写的地址。

而该程序的工作方式为启动一个 main 工作流负责进行端口监听,当有新请求来时, fork 一个子进程处理该请求,因此每次请求都是从主进程 fork 的子进程,都与主进程拥有相同的内存空间,共享 mmap 地址,所以,可以爆破出 mmap 的值。

而在当前的调试环境中可以发现,各模块的加载顺序是相对固定的,而 afpd 模块在地址是高于 libc 模块的。而这些基地址都是 0x7fxxxxxxx000 的形式,因此通过从高到低只需要爆破中间的字节即可,通过从高到底爆破可以爆破出最高的一个可写入地址,因此还是比较快的。

而拿到可写入地址后,通过地址对齐可以得到最高模块的地址,然后通过向前跳页,不断尝试总能尝试到libc的起始地址,一旦尝试成功则可以完成利用。

mmap

0x02 环境构建

这里使用参考链接中的现成环境即可。如果要搭建自己的环境,所有下载的文件为: netatalk-3.1.11.tar.bz2 libc-2.27.so libatalk.so.18.0.0 afpd

使用下列命令启动容器后,可以启动该服务。

1
sudo docker run -p 548:548 -it --privileged=true cve-2018-1160:v1 /sbin/init

要调试这里面的程序,使用下面的命令

如果有 pwndbg 等插件记得修改配置文件

1
sudo gdb -q -p `pgrep -n afp` --ex "set follow-fork-mode child"

0x03 直接利用

通过 gdb 挂载目标程序,查看内容中的 libc 基地址,如下图所示:

mmap-1

mmap-2

所以可以得到libc的基地址是 0x7ffff67a1000

在客户机监听起来:

1
nc -lnvp 6666

然后在 exp 中填入该基地址,再运行 exp 即可成功执行命令,实现getshell。这里是在 exp 中执行了一个 ls 的命令,可以看到成功在对方机器执行了命令了。

exp执行成功

0x04 调试分析过程

修改 commands 为 free_hook

在漏洞点前下断点,发送exp,此时观测内存中的数据,可以发现此时一切正常,我们根据定义将对应的一些域标注出来,具体包括:

1
2
3
uint32_t attn_quantum, datasize, server_quantum;
uint16_t serverID, clientID;
uint8_t *commands; /* DSI recieve buffer */

下断点

1
b dsi_opensess.c:34

调试查看DSI结构体中的数据

然后在漏洞点后下断点,观测此时内存中的数据

1
b dsi_opensess.c:35

可以看到,此时从 attn_quantum 开始的数据都被覆盖掉了,其中 commands 被修改了 __free_hook 的地址了。

attn_quantum被覆盖

因此,第一次发送数据可以完成修改 commands 为希望覆写数据的位置即 __free_hook

修改 free_hook 的内容

写入第二次 payload 后的内存

可以看到 __free_hook (0x7f46c66468e8)处的值已经是 __libc_dlopen_mode_56 了。而紧随其后的 __free_hook+8 (0x7f46c66468f0) 处的值就是填充的执行命令和padding。

而同时_dl_open_hook (0x7f46c6649588) 处的值是 _dl_open_hook+8 (0x7f46c6649590) ,然后加入的值是 fgetpos64+0xcf (0x00007f46c62d79cf) ,然后是0x18的填充后,在(0x7f46c66495b0)处的值是 setcontext+0x35 ,而紧随其后是 0x40 的填充,然后是在 (0x7f46c66495f0)处 写入了 __free_hook+8 ,而后是 0x30 的填充,然后在 (0x7f46c6649630)写入了 __free_hook 并紧随其后在 (0x7f46c6649638)写入了 system 。之后,只要一触发 free 即可劫持控制流。

内存排布结束

具体的控制流劫持流程如下所示,通过上面的布置后,一旦程序进入 free 控制流,然后就会调用 __free_hook 函数,而此时该函数已经被劫持到右侧的ROP上,通过第一段ROP的执行,程序会跳转到第二段ROP执行,并且此时会有 rdi=rax=_dl_open_hook+8 从而实现控制 rdi 寄存器,从而实现利用 SROP 进行控制流劫持。具体 SROP 的工作流程是,在 _dl_open_hook+8+0x20 处布置第三段ROP,第二段ROP执行完毕后会跳转到第三段ROP,而此时通过 rdi 的位置,布置相对偏移实现其他寄存器的操作,具体这里是通过布置 rdi+0xa0 以修改 rsp 的值进行栈迁移,布置 rdi+0xa8 以修改 rcx 的值,布置 rdi+0x68 以修改 rdi 的值。而在执行过程中 rcx 会被压栈一次,并在结束时 ret 以修改 rip 的值,劫持控制流。

control_flow_hijack_chart

在 (0x00007f46c63bf318)处下断点后 continue 跟踪程序释放流程,可以看到目前处于第一段 ROP 中。

程序处于释放流程

然后走两步,观察到进入了第二段ROP中,并且可以看到 rax 的值也是预期之中的。

rax符合预期

于是再走两步,可以看到程序进入了第三段ROP中

第三段ROP

此时,慢慢跟该段代码的执行,观测重要寄存器的修改 rsp rcx rdi

再单步执行一步,可以看到 rsp 已经被成功修改为 __free_hook (0x7f46c66468e8)

rsp被修改为 free_hook

然后再执行很多步,我们去观测 rcx 的值,可以看到此时 rcx 的值已经被写成了 system ,并且下一条指令将会把 rcx 的值压栈,因此 system 将会被压入栈顶中。

rcx 被覆写为 system

再执行一步,可以看到 system 此时被压入栈中,并处于栈顶。

system 被压入栈中

然后一直执行到 rdi 被修改,可以看到此时 rdi 指向了我们将要想执行的命令的字符串。并且下一条要执行的指令为 ret ,而此时栈顶的元素依然是 system ,让 ret 执行后,此处栈顶的 system 将会被弹出到 rip 中,从而完成劫持控制流。pwndbg已经帮我们预判了后续的执行流程。

预期的控制流

成功劫持控制流示意,此时 rip 就是 system 而对应的参数 rdi 指向我们想执行的命令的字符串。

rip 成为 system

0x05 总结

这个漏洞的利用过程还是比较巧妙的,灵活使用 pwntools 确实可以起到事半功倍的效果, SROP 的方法比自己人工找去配置确实方便多了。

要完成利用还是需要比较熟悉程序,对漏洞的能力需要有比较清晰的认识。基本思路还是要确定漏洞点、然后拿到任意地址写之后判断了写入能力为写任意内容就容易了很多,然后在利用 fork 机制爆破泄露 libc 的基地址最后结合ROP链实现了控制流劫持。

0x06 参考

环境和exp 看雪 。漏洞详情分析 I3r0nya